Arc Post-Mortem Summary. Started as authentication modernization and grew a second thread after a production emergency showed the single-process atto web server blocks every connection during long work (a huge mailbox listing, an external IMAP probe, a password hash). It landed the immediate server-stability fixes and then the cooperative non-blocking server, which lets long work yield to the event loop and be re-entered, starting with IMAP. The authentication rework proceeded alongside in the originally-listed order.
Renamed from “Auth Modernisation” on 2026-06-19 once the arc grew a second thread: a production emergency exposed that the single-process atto web server blocks every connection whenever a request does something long (a huge local-mailbox listing, an external IMAP probe, a password hash). The stability fixes for those landed under “Server stability work” below, and the next thread is the cooperative non-blocking server that lets long work yield to the event loop and be re-entered — starting with IMAP.
Arc started 2026-06-19; revised the same day after dropping TOTP.
Covers the v10 ToDo Phase 3 items in the order they were
originally listed: item 32 (modernise the image captcha) and
item 33 (replace the recovery-question system). Item 33(a) —
default new installs to
EMAIL_RECOVERY — and item 47 — the
database-backed session store — already landed.
IMAGE_CAPTCHA
(CaptchaModel draws letters with GD's built-in bitmap
font) and HASH_CAPTCHA — a proof-of-work mode
where the browser runs hash_captcha.js /
sha1.js to find a nonce meeting a difficulty
(HASH_CAPTCHA_LEVEL). The image mode is the one being
retired.NO_RECOVERY (0),
EMAIL_RECOVERY (1), EMAIL_AND_QUESTIONS_RECOVERY
(2); the default is already EMAIL_RECOVERY (33(a) done).
Recovery questions are driven by fixed
register_view_recoveryN_* locale keys.IMAGE_CAPTCHA mode (the constant, the
CAPTCHA_MODE setting, the admin Captcha-Type
control, the RegisterController image branches, and
the now-dead image blocks in the register / recover / resend /
suggest views). CaptchaModel and
setupGraphicalCaptchaViewData stay alive for the
wiki placeholder, converted next. This patch.
border-top on both (the
multi-field register / suggest forms keep theirs, where it still
reads as an action separator).{{image-captcha}} now
expands to a hidden field whose browser solves a proof-of-work
puzzle (no typing, no author-chosen type). A shared
meetsProofOfWork on the base controller does the
check; setupGraphicalCaptchaViewData became
setupCaptchaViewData (keeps the per-render word that
seeds the form's dedup hash, drops the image), and
WikiElement renders the proof-of-work inputs.
SocialComponent verification swaps the typed-word
match for the puzzle check while keeping the existing
new-row / reload / dedup behaviour. CaptchaModel and
the image plumbing are removed. Kept
{{keyword-captcha}} — an author-chosen
keyword is a deliberate tool. This patch.
{{image-captcha}} syntax entirely (no legacy support)
and made the proof-of-work automatic on every
wiki form. Renamed to disambiguate — the
anti-bot check is a proof-of-work form check, not a
captcha: token [{hash-captcha}] →
[{proof-of-work}], and
setupCaptchaViewData →
setupProofOfWorkViewData. One consistent pair does the
work: WikiParser::proofOfWorkFormFields() injects (from
GroupModel::setPageName when it wraps a form) and
Controller::meetsProofOfWork() checks. The proof-of-work
is always required; {{keyword-captcha}} is an
additional gate, so a form carrying one must pass
both. The injected answer field is skipped only when a keyword
captcha already supplies one (it shares the form's key column), so
there is never a duplicate field. Removed the dead
system_component_image_captcha /
_hash_captcha locale labels.aria-hidden,
autocomplete="off") is injected into every form by
proofOfWorkFormFields; processWikiFormData
rejects a submission that fills it, one that comes back faster than
MIN_WIKI_FORM_DELAY seconds, or one from an address
already inside the per-IP backoff (a new up-front
getVisitor gate reusing the same
captcha_time_out timeout the account forms use). All
three answer with the generic captcha-failed message and feed
updateVisitor, so a script cannot tell which check
caught it and repeat trips grow the timeout. This patch.email_registration type adds the user inactive,
sendActivationMail sends a DKIM-signed message with a
verify link, and emailVerification activates the
account. Audited for deliverability and security; the findings are
being addressed in sequence:
DATE_RFC822), a spam-filter signal
on every outbound message; now DATE_RFC2822 in
MimeMessage::build.==; now hash_equals so
the check runs in constant time.Reply-To and was plain text.
Done: activation mail now sets a Reply-To from
the new MAIL_REPLY_TO config (defaults to the
sender mailbox, can be pointed at a staffed support address),
and the body got a light HTML face lift. It now goes out as
multipart/alternative — a plain-text part
(kept for text-only mail apps and the spam filters that
expect one) and a light HTML part with the activation and
unsubscribe links made clickable and the user-supplied names
and URLs escaped — assembled by a new
MimeMessage::alternativeBody helper and carried
through by letting MimeMessage::build honor a
caller-supplied Content-Type.EMAIL_RECOVERY — already done.LOGIN_CODE table (migration
upgradeDatabaseVersion113, DATABASE_VERSION
now 113), 8 chars from an unambiguous alphabet, 15-minute lifetime
(LOGIN_CODE_TIMEOUT), at most 5 wrong guesses
(LOGIN_CODE_MAX_TRIES). Storage lives on
SigninModel; the controller (loginCode,
processLoginData, loginCodeComplete)
emails the code and a one-click button carrying a signed
token plus the code, both verifying the same stored hash and
signing the member straight in. The request form is reached from a
new link on the sign-in page and is shown by a method on
SigninView. Per-IP and per-email
ONE_MINUTE throttling and a generic anti-enumeration
message mirror the resend flow. Covered by
SigninModelTest.register_view_recoveryN_* translation
infrastructure and remove the
EMAIL_AND_QUESTIONS_RECOVERY mode.WebSite.php): a peer whose TLS faulted mid-stream (a
“bad record mac” alert) left its socket perpetually
readable, so the event loop spun at full CPU and
cullDeadStreams never reaped it (the read path kept
refreshing its activity time), which eventually blocked all new
connections including the command-line restart. Fix: in
parseH1Request, a readable socket whose read returns
false (TLS fault) or empty at end-of-file is now torn
down at once with shutdownHttpStream; the read's warning
suppression also swallows the SSL operation failed notice
(same client-side transport class as the existing “reset by
peer”); and the activity-time refresh in
processRequestStreams is guarded so a connection closed
during the read leaves no stray timer.read_made_progress flag is
reset before each connection is handled and lowered by the H1 read
when a readable socket returns no bytes yet is neither at end-of-file
nor errored (a stalled or partial-record peer); only when the read
actually pulled bytes does processRequestStreams refresh
the time. So even a peer that stays readable while delivering nothing
ages out on the idle timeout instead of holding the loop. Other
protocols leave the flag true, so their behaviour is unchanged.MailSite.php): checked the mail server for the same
class of bug. The stalled-TLS-handshake case is already safe —
handshakes are non-blocking with a 10-second deadline, watched
read-only (so a connected, always-writable socket cannot spin the
loop), and cullDeadStreams reaps any handshake past its
deadline every tick (select is capped at 5s). The idle path was also
already safe (it stamps the activity time only after a non-empty
read), and the write path already closes on a failed write. The one
gap, the same one fixed in WebSite.php: an established
secure connection whose read faulted (a false return)
just returned and spun until the 30-minute idle cull. Fixed
readClient to close at once on a read fault or an
end-of-file read, matching the web server.ImapClient.php): checking mail in the web UI could hang
the whole site. The STARTTLS path called
stream_socket_enable_crypto on a blocking socket, which
ignores the read timeout — so an IMAP peer that accepted
STARTTLS and then stalled mid-handshake blocked the single-process web
server forever, and no other web request could be served. Added
enableCryptoBounded, which runs the handshake
non-blocking and waits only up to the connect timeout
(MAIL_IMAP_CONNECT_TIMEOUT, 15s) before giving up;
connectStartTls now uses it. The implicit-TLS path
(connectImaps) already bounds its handshake through the
stream_socket_client connect timeout. (SmtpClient
has the same blocking-handshake pattern but runs in the background mail
sender, not a web request, so it is a lower-priority follow-up.)MailSiteMailBackend::listMessages): opening a large
MailSite (local) inbox in the web UI froze the whole single-process web
server, blocking every other connection. The listing shaped
every message in the folder — reading one header block per
message — before paging down to one window, so a 90,000-message
INBOX did ~90,000 small file reads per window request. An SSD hid the
cost (the first window returned, slowly); a spinning disk turned it into
minutes of seeks that hung the server, and later windows failed. Changed
to window-then-shape: the date sort, the unread/flagged filters and the
windowing now run on the lightweight index records (which already carry
uid, date and flags), and message files are read only for the ~25
messages on the returned window. Added
listMessagesWindowReadsOnlyWindowTestCase (a counting
MailSite subclass) asserting that listing one window of a 60-message
folder reads at most one window's worth of message headers, so a return
to shaping the whole folder is caught.MailUnreadProbe, Component,
SocialComponent::userMailBaseData): the unread-mail badge
in the shared page chrome was computed by connecting to every external
IMAP account (connect + LOGIN + STATUS) on a cache miss, inside the
single-process web request. So one user logging in (or any page view
after the 300s cache expired) with a slow or unreachable external
account froze the whole server for every other visitor. Split the badge
into a cache-only read used by the page chrome
(MailUnreadProbe::cachedCount, which never opens a
connection and returns the last cached value or 0) and the existing
computing count(), now called only from the Mail activity
(userMailBaseData), where talking to the mail servers is
expected and the listing already connects. General browsing and login
no longer wait on mail; the badge catches up the next time the user
opens their mail. (Making even the Mail activity's external-IMAP calls
non-blocking is the separate async-IMAP architectural follow-up.)\Fiber
tasks so a deep mail-socket read can suspend the whole request without
rewriting the controller chain above it; deferTask takes a
callable, wraps it in a fiber under atto, runs it inline under
Apache.header_data/content_type/
current_session) is swapped around every resume and its
output captured per resume; the response is built and sent only when
the fiber finishes (a 500 if it throws or runs over budget). Wired for
HTTP/1.1, HTTP/2, and HTTP/3 (the last through H3Listener);
the H1 round trip is core-tested in the sandbox, the live protocols
need testing on the server.ImapClient. Reads
(readLine/readBytes) and the bounded TLS
handshake now hand the loop back with
Fiber::suspend(['read' => $sock]) at a would-block
point when inside a fiber, and block exactly as before otherwise (so
Apache and non-cooperative callers are unchanged); the loop polls
in-flight tasks at a short interval (COOPERATIVE_POLL)
rather than spinning. A socket-pair test proves a read suspends until
the server answers. (Wiring the wait sockets straight into the loop's
select to drop the poll is a possible later refinement; writes block
briefly as now.)a=userMail, bar attachment downloads) now run inside a
deferred fiber, so the folder and message listing, message view, and
the unread-count probe — everything that reads IMAP — yield
to the loop while a slow or down mail server is being waited on rather
than stalling every other connection. For this, deferResponse
now treats a webExit() (redirect or normal early exit) as
a clean finish, the way the synchronous path does, instead of a 500.
Under Apache the requests run straight through unchanged.crawlCrypt, cost 12, a good fraction of a second) cannot
be split, so when it runs inside a fiber it is handed to a fresh
short-lived PHP process (started with proc_open(PHP_BINARY...);
chosen over forking the live event-loop server, which would inherit
its sockets and is not portable). The password and salt go to the
helper over its stdin (never the command line), and the login task
Fiber::suspend(['read' => $pipe])s until the helper
writes the hash back. Outside a fiber crawlCrypt is the
plain crypt it always was, so every other caller and Apache are
unchanged. Signin requests (those carrying u and
p) are now deferred so the hash actually runs in a fiber.
A test proves the helper produces the identical hash and the fiber
suspends on its pipe.in_array over every thumbnail, making the whole loop grow
with the square of the count — it is now an O(1) keyed lookup; and
the assembly loop in GroupModel gives the loop a turn every
RESOURCE_LIST_YIELD resources (via
cooperativeYield(), a no-op outside a fiber), with wiki
requests now deferred so that yield takes effect. To keep that yield
free, the scheduler now tells the loop when it has immediate work (a
task that suspended plainly, not on a socket), so a CPU- or disk-bound
job is resumed at once rather than waiting out the poll interval, while
socket waits are still polled gently.SmtpClient. Its response read used a
blocking fgets and its STARTTLS handshake a single
blocking call, both of which froze the loop waiting on the mail
server. The reader is now buffered the way ImapClient's
is and waits for the socket to be readable (suspending the fiber when
cooperative, blocking exactly as before otherwise), and the STARTTLS
handshake uses the same bounded, fiber-yielding upgrade — which
also fixes its TLS version to 1.2/1.3 only, dropping the obsolete bare
method that allowed TLS 1.0/1.1. The web requests that send mail
(registration, group feeds and group management; userMail
was already deferred in Slice 3) are now deferred so those sends run in
a fiber. A socket-pair test proves a response read suspends until the
server answers. The plain (non-implicit-TLS) TCP connect is now
cooperative too: in a fiber it asks for a non-blocking connect and
suspends until the socket reports it has connected, so a slow or
unreachable server no longer freezes the loop during the connect
either (tested against a local listener); outside a fiber it is the
same blocking connect as before. The seconds-to-microseconds
conversion the bounded handshake and the loop clamp use is now the
named constant MICROSECONDS_PER_SECOND rather than a bare
1000000. (Two blocking points remain, both rare and bounded by the
connect timeout: the implicit-TLS, port-465 connect, whose handshake
is part of the connect; and the IMAP client's connect, which can take
the same async treatment next.)sendImmediate moved to a worker process.
Reconsidered the SMTP approach: rather than make each socket step in
the web process cooperative, a send done inside a fiber now hands the
whole exchange to a short-lived helper process — the
same way the password hash gets off the loop — and suspends on
the helper's pipe until it answers. The connect, the TLS handshake,
and the conversation all happen in the helper, so none of them can
block the loop, and the helper's success flag and log are copied back
onto the client. The per-I/O suspend code added in Slice 6 (the
buffered reader, the bounded handshake, the async connect) is removed
as no longer needed; the TLS-1.2/1.3 fix is kept. Bulk mail already
goes through sendQueue and the background updater, so only
the occasional sendImmediate reaches a helper. A test
drives a send in a fiber against a closed port and confirms it
suspends on the helper pipe and carries the failure back. The helper
discards any stray warning markup so only its JSON result reaches the
pipe.FileMailStorage.
This closes the freeze the whole arc was opened for. Clicking the
local inbox runs, in the web process,
MailSiteMailBackend->listMessages → in-process
FileMailStorage, which takes a shared flock
on the folder index while the mail daemon may hold it exclusively;
Slice 3 put that request in a fiber, but a synchronous
flock cannot suspend, so the loop froze. The four
read-path shared locks (readFolderIndex,
readReuidJournal, readFlagsSnapshot,
liveUidsFromIndex) now go through a
cooperativeFlock helper: inside a fiber it asks for the
lock without blocking and, if it is held, hands the loop a turn and
retries; outside a fiber (the daemon, the command-line tools) it is a
plain blocking lock, unchanged. The local backend now yields like the
IMAP backend already did. Tested: a shared-lock read started in a fiber
suspends while the file is held exclusively and acquires the moment it
frees.The MailSite daemon's own listen() loop (a
separate process, the same single-process stream_select shape
WebSite had) still blocks one mail connection on another. Making it
cooperative is a much bigger surface and raises the scheduler question
(CooperativeScheduler lives in WebSite.php and atto files are
self-contained, so the daemon would get its own copy of the step logic
rather than reaching across files). That work, plus auditing the rest of
the atto servers for the same blocking pattern and writing worked examples
of the new fiber primitives, is its own arc, started right after this
patch.
emitDeferredH2
fetched a now-null connection object and sendH2Body
faulted taking a reference to protocol_state on null
(uncaught Error in the cooperative loop). It also queued the
response headers first, orphaning those bytes in the out-stream
buffer for a connection that would never drain — a slow leak
that can feed the out-of-memory crashes. emitDeferredH2
now returns as soon as it sees the connection is gone (before
queuing anything), and sendH2Body has a matching
defensive guard.word-wrap: break-word, which does not let a long
unbroken token shrink the column. The cells now use
overflow-wrap: anywhere so such tokens break, and the
table is capped at max-width: 100% so it can never
exceed its container.BODY[...]<offset.count>). Apple Mail kept
re-requesting the same message in a loop: it asked for a byte range
with UID FETCH ... BODY.PEEK[TEXT]<4181.1215>, but
the server ignored the range and returned the whole section with no
origin-octet marker, so the client rejected the reply and retried
forever (and never showed the activation mail). The FETCH-item parser
now reads the <first.count> suffix and the renderer
returns just that slice, labelled BODY[TEXT]<first>
as RFC 3501 7.4.2 requires; a count past the end yields the shorter
remainder and non-partial fetches are unchanged.[x / y] passed summary on each test method now prints
green when all of that method's tests passed and red when any failed.
The colour is added only when output is an interactive terminal (a log
or pipe stays plain), and Windows VT100 support is switched on first so
the same codes work on both Windows and Unix-like terminals.H3Listener called
$site->setStreamingProtocol() and
takeStreamingProducer(), which never existed on
WebSite (the H3 request path would have fatalled).
Implemented both, and let stream() park a producer for
h3 as it already did for h2, so the HTTP/3
path can run as intended.tl("key") calls. Fixed
every place a key reached tl() through a variable, a
concatenation, or a nested call. MailElement's header
and DKIM-detail row helpers now translate at the call site
(tl("literal")) rather than inside the helper; the
scheduled-status and DKIM-summary labels use explicit per-value
tl() branches instead of
tl("prefix_" . $status); the IMAP/SMTP TLS-mode
dropdowns pre-translate their option arrays; and
StoreComponent's doubled tl(tl(...)) (the
bogus "tl" missing string) became a single call. Added the four
genuinely-untranslated keys
(machinestatus_view_no_queue_server,
machinestatus_view_no_fetchers,
accountaccess_component_activityname_doesnt_exists,
accountaccess_component_modifier_doesnt_exists) to
configure.ini. Verified by re-running the extractor's
own regex against the touched files: zero bogus captures, and every
previously-pruned key now extracts as a literal. Rendered output is
byte-identical.src intact and adds
loading="lazy" plus a
mail-blocked-image class
(display:none): a lazy image with no layout box
is never near the viewport, so the browser does not fetch it
and no tracker pixel fires on render. The "load images" button
removes the class; each revealed image then loads natively,
directly from its host. This went through a same-origin proxy
first (every image, then narrowed to an onerror
fallback for the few hosts that send a
Cross-Origin-Resource-Policy header and so refuse cross-origin
embedding), but the proxy was removed entirely: its server-side
fetch is a blocking curl_exec in
FetchUrl::getPage, and atto is a single-process
cooperative server, so each proxied image stalls the whole
event loop for the length of the remote fetch. Over HTTP/2 the
page document and every other resource multiplex on one
connection, so a single blocked fetch truncates the in-flight
page itself (NS_ERROR_NET_PARTIAL_TRANSFER) and
aborts queued requests. Direct loading is the rule and the only
rule. The cost: a handful of CORP-locked images (the sign-in
mail's logo among them) show broken after "load images"; every
other host loads fast. Restoring those would require a
cooperative (yielding) fetch on atto rather than a blocking
one -- deferred as future work, not built here.WebSite::deferResponse), and
ImapClient's reads already suspend through
waitReadable() -- but only when the socket would
block. A mail server streaming a large message body keeps the
socket continuously readable, so readBytes() read the
whole literal in one tight 4096-byte loop that never yielded,
monopolising the single-process server for the length of the
transfer. On a heavy message (Peacock/Zillow) that froze the whole
site while the body came in, and starved the sibling resource
requests on the same HTTP/2 connection -- which is why
mailmessages.js could arrive truncated and the "load images"
button came up dead until a reload. readBytes() now
counts bytes pulled from the socket and, once past a
COOPERATIVE_READ_YIELD (64 KB) run, suspends the
fiber for one loop pass before continuing, so every other
connection makes progress during a big fetch. Small messages read
straight through with no yield; outside a fiber (Apache) the
Fiber::getCurrent() guard skips it and the read blocks
as before. Verified by driving a 293 KB read inside a fiber
over a socket pair: it yields several times and returns the
complete payload. The DKIM verify's dns_get_record is
a separate synchronous blocker on the same view path and is left
for follow-up (a blocking resolver cannot be made to yield without
an async DNS path).mail-open-message class; a
new initMailMessageNav intercepts a plain left click
on one, fetches that same URL, lifts the
.account-messages content out of the returned page,
swaps it into the current pane, and re-runs
initMailMessageView to wire the new view's controls.
The shell, its scripts, and its styles stay put, so opening a
message is now an in-place swap rather than a reload. Only the
three message-view controls need re-wiring (raw toggle, load
images, DKIM badge); reply / forward / back are ordinary links and
move / delete are self-contained forms with inline handlers, so
nothing else is stranded by the swap. Modifier and middle clicks
are left alone so open-in-new-tab still works;
history.pushState keeps reload and the back button
honest (a back navigation reloads whatever the address bar then
names); and any failure -- a network error or a response without
the pane, such as a re-login redirect -- falls back to an ordinary
navigation so a click is never lost. The server still renders the
full page and only its message pane is used; a dedicated fragment
response would save that extra render and is a sensible later
optimisation. Going back to the inbox stays a normal navigation, so
the inbox's own many handlers re-wire the ordinary way.initMailLoadImages) and matched by
closest('.mail-message-load-images-button'), so it
fires for the button no matter how or when the message view enters
the page, with no dependence on a re-init running at the right
moment. The reveal is also made deterministic: as well as dropping
the mail-blocked-image class it removes
loading="lazy" and re-assigns each image's
src, which starts the fetch even for an image that
arrived through innerHTML -- where a still-hidden lazy
image otherwise never begins loading, the case that left "load
images" looking dead until a reload.mail_element_loading, passed to the script as
window.MAIL_LOADING the same way the triangle glyphs
are) the moment the fetch starts, and the message replaces it on
arrival. The spinner is pure CSS and honours
prefers-reduced-motion.%s, and
it is emitted into a double-quoted
data-mail-element-folder-delete-confirm attribute, so
the quotes closed the attribute early and the page no longer
validated. The other strings in that span carry no quotes, matching
the span's plain-attribute convention; the confirmation string is
brought into line by using single quotes around the folder name,
which leaves %s intact for the script's substitution
and needs no change in the view or the script.mail-col-resizer is now placed in the star header
cell (resizing the star column against From), and the redundant
cell border it replaces is removed so the boundary is drawn once.
The drag clamp is also corrected: it previously refused any move
while a column was under the minimum width, which froze a handle
next to the narrow star column once the list pane was wide; it now
blocks only a column that is actively shrinking past the minimum,
so an already-narrow column can still be widened.display:none) showed at the top and fixed desktop
widths read poorly on a phone. Inline style is now filtered rather
than dropped: a curated property allowlist (colour, typography,
spacing, borders, lists, display, visibility) is kept, while
anything that can lift content over the page (position, z-index,
float) is dropped, any value carrying a hazard
(url(), which would beacon a tracker and bypass the
remote-image block, plus expression(),
@import, and escape tricks) is dropped, and a
fixed-pixel width is dropped so a message stays fluid
in a narrow pane (a percentage or max-width is kept).
Because a blocked image may now carry its own
display:block, the sanitizer strips display and
visibility from blocked images and the hiding rule is marked
important, so a tracker image cannot show -- and therefore load --
through its own style. New unit tests cover the kept/dropped split,
the width filter, and the blocked-image display strip; a stale test
still asserting the superseded data-mail-src blocking
contract was corrected to the current lazy-plus-class one..mail-message-body-html table gets
max-width: 100%, matching the existing rule on
images), so an over-wide table wraps to fit. A wide desktop pane is
unaffected: the cap only binds when a table would otherwise exceed
the pane, so a 600px newsletter still renders at its natural
width..mobile p rule gives every paragraph a 300px minimum
width, and that rule also matches the paragraphs inside a rendered
message. In a two-column newsletter each column then demanded
300px, so the layout table could not shrink below ~600px and the
body overflowed regardless of the max-width cap (an intrinsic
minimum beats a maximum). The minimum is now reset to zero for
message paragraphs and table cells
(.mail-message-body-html p/td/th/table), so the
columns collapse and the text wraps; because mail.css loads after
search.css the equal-specificity reset wins.max-width: 700px together with a
width="600" attribute, and an inline maximum outranks
the rendering rule's max-width: 100%, so it drew at
600px and overflowed a phone. The image cap on
.mail-message-body-html img is now marked important so
the responsive cap (and height: auto with it) wins
over whatever width the message sets inline; an image can never
exceed its column and stays in proportion.« arrow in the view, outside the
translated string, then appended the translated "Back to %s". A
right-to-left locale could never move or mirror that arrow, because
the directional glyph lived in the markup rather than in text a
translator owns. The arrow now lives inside the translated string
and the "Back to" wording is dropped, so the English value is
"« %s" and the link reads "« Inbox"; a
right-to-left locale can place the arrow on the trailing side in its
own value. Only the English value is changed here; other locales
keep their wording until retranslated.upgrade-locales command that force-pushes the locale
strings together with the version-gated wiki and help pages and
resource files from the source tree into the work directory,
regardless of the stored version. The library
upgradeLocales() gained a force argument that drives
that unconditional push; its normal version-gated behavior on
startup is unchanged.DkimKey::ensureKeyPair(), but the version-functions file
sits in the library namespace and never imported the class, so the
bare name resolved to a nonexistent library-namespace class and threw
a fatal class-not-found. It now imports the mail
DkimKey class, the same way the profile model and the
bulk-email job do. The step was latent in development because a
database already at the current version never runs it.MailSiteFactory, so rather
than fix it alone the whole library-root was scanned (tokenized, so
string literals and docblock mentions are not false positives) for
any class used as X:: or new X that lives
in a library sub-namespace yet is neither imported nor defined in the
library root. The only real hit besides the already-fixed
DkimKey was MailSiteFactory; both are now
imported, and the sweep reports nothing else outstanding.\g or \u from feed text)
made PCRE2 fail to compile the pattern and logged a warning on every
such term. Each of the four match sites now passes the term through
preg_quote so it is treated as literal text. The same
pass fixed the long-dead possessive branch: a word like
engineering_pos_s (the tokenizer's marker for
"engineering's") never matched because the guard compared against a
back-slashed "\_pos\_s" instead of the six-character
_pos_s marker, then built its pattern from the marker
rather than the word, and required whitespace after the apostrophe
that "engineering's" does not have. It now strips the marker, builds
the pattern from the word, and allows the apostrophe to be followed
directly by the trailing letters, so possessives match in plain
(engineering's), entity ('s), and
spaced forms.bot it generates a fresh random
secret, writes it to work_directory/security/bot_security.txt
(owner-readable only, beside the TLS key), and sends it as the
password; the mail server's authenticator, seeing the bot login,
compares the supplied password against that file in constant time
instead of a stored credential, and on a match empties the file so
the secret is single use and cannot be replayed (a wrong guess
leaves it in place so it cannot wipe a secret a real send is about
to use). Because the sender and the mail server are the same
machine, the file is always shared between the two ends that need
it. A unit test covers the accept-and-consume, reject, replay,
empty-file, missing-file, and bot-name cases. Along the way: the
SMTP client field on the bulk-mail job was renamed from
mail_server to smtp_client and the same
variable in the registration controller from server to
smtp_client (both hold an SmtpClient, not a server),
and a pointless local copy of the username in the password check was
removed.SigninModel::checkValidSignin that slept each login
up to 0.25 / 0.5 / 1.0s of wall clock. That pad was both slow and
redundant: the routine already runs a real bcrypt in the
missing-user branch, so the expensive work is already constant
whether or not the account exists. Removed the sleep and changed the
password compare from == to hash_equals, so
the timing-attack defence is now the constant bcrypt plus a
constant-time compare instead of a wall-clock pad.crawlCrypt handed each bcrypt to a freshly spawned
php -r helper so the cooperative loop would not block.
A standalone probe put cost-12 bcrypt at ~0.29s on the (x86_64)
pollett.org box — less than the ~0.09s it costs just to spawn
the helper, plus the Apple-Silicon scheduling variance the offload
added (one run swung an AUTH to 1.3s). On a single-process personal
server logins rarely overlap, so the offload was paying that
overhead on every login for a benefit never collected. Dropped the
offload (crawlCryptOffload removed) and compute
crypt() inline: deterministic ~0.3s, no spawn. The
bcrypt cost stays at 12 (the PHP 8.4 / Laravel 11 default). A
separate experiment that turned the daemon's parked-fiber busy-spin
into a 2ms sleep was tried and reverted — on Apple Silicon the
sleeping loop let the hash helper drift onto efficiency cores and
ran slower, so the spin stays.ensureStandardFolders,
run on every login, at ~355ms (and several seconds on a cold cache).
It called listFolders inside its per-role loop, walking
the whole mailbox tree four times even when every canonical folder
already existed and nothing needed consolidating. It now lists once
and reuses the result, creating a canonical folder only when it is
missing and entering the message-move path only when an alias folder
("Spam", "Sent Items") is actually present.folder_generation.txt token that
createFolder, deleteFolder, and
renameFolder rewrite (staged to a temp name and renamed
into place) on any change in any process.
listFolders reads the token first and only re-walks
when it differs, so a folder created or deleted in the web interface
shows up live in IMAP, while an unchanged mailbox costs one small
read instead of a tree walk. collectFolderTree also
rejects .eml message files with a single suffix test
before its fuller metadata checks. Net for this run: IMAP AUTH on
pollett.org fell from ~1.08s to ~0.35–0.44s, now floored by
the cost-12 hash itself.yioop.com carried links reading
https://localhost/. BASE_URL was a
constant built once at start-up from
$_SERVER['SERVER_NAME']; under the single-process atto
server that is the one name the server bound to (often localhost)
and never the domain a given visitor used, and a server answering to
several domains has no single correct constant. Since the value is
genuinely per request, it can no longer be a constant: it is now a
function C\baseUrl() in Config.php,
computed nowhere else. It reads the host (and scheme and port) from
$_SERVER, which the web server in front of Yioop sets
per request — atto, Apache, and nginx alike — so the
same code is correct under any of them; the fixed path part stays
the SHORT_BASE_URL constant. The bootstrap that used to
assemble the constant was reduced to setting that path and seeding
SERVER_NAME from the launch address; the scheme rule
(Apache's HTTPS "off", and the
own-web-server TLS-context fallback gated on
IS_OWN_WEB_SERVER) moved into the function. Every
C\BASE_URL use across the tree (controllers, models,
views, library) was changed to the call; the three
nsdefined("BASE_URL") guards collapsed into
the function, which returns NAME_SERVER for the
command-line tools that previously relied on that fallback. Outside
a request, where no host is in $_SERVER, the host falls
back to the first configured served name and then the
operating-system name from gethostname. Under atto the
served name is made trustworthy at a lower level than the
controller: the launcher passes the parsed
SECURE_DOMAINS into the WebSite config
(top-level, so it survives secure mode where
SERVER_CONTEXT is unset), and the self-contained
WebSite sets $_SERVER['SERVER_NAME'] per
request to the visitor's Host when it is one of those
names, or the first served name otherwise — the allowlist
check keeps the attacker-controllable Host header out
of the links.UtilityTest's cooperative-hash case still asserted the
old behaviour — that inside a fiber crawlCrypt
offloads to a helper and suspends on its pipe — which the
inline-hashing change had removed; it now asserts the real
behaviour, that the call runs to completion in one step inside a
fiber and returns the same hash as a direct call. Second, an
intermittent failure that surfaced only after the suite had been
run many times in a row on one work directory was root-caused to
the data-source connection cache. The mail-model tests
(MailCloneModelTest, MailAccountModelTest,
MailSuppressionModelTest,
MailAliasModelTest) each open a throwaway database
whose file name carries only a small random tag, and the data-source
layer keeps a process-wide cache of open handles keyed by path. A
plain disconnect nulls the handle but does not drop it from that
cache, so when two tests in one run happened to draw the same tag
the second was handed the first's still-open handle — rows and
all, since the unlinked file stays alive through the handle —
and assertions such as "two claimable jobs" saw leftovers
and failed. Each of these tests now evicts its own handle from the
cache in tearDown (keyed by the connection string, so
shared handles are left untouched). Third, the UnitTest
runner called a test method and then tearDown without a
finally, so a method that threw part way through skipped its
tearDown and the eviction above; tearDown
now runs in a finally so it always cleans up.MailSiteFactory::isForbiddenAccountEmail holds the rule,
with the decision in the testable accountEmailRefused.
The off-by-default MAIL_REFUSE_LOCAL_DOMAIN_ACCOUNTS
extends that to every address on a managed domain, so an in-house
install where everyone shares the domain still works by default. Bot
side: the existing check that refused renaming the bot onto an
existing username now also refuses renaming it onto an
address an existing account already uses as its email (new
UserModel::getUserByEmail). Covered by
MailSiteFactoryTest and UserModelTest. Also
reworded the login-code verify screen (title “Sign-in”,
field label “Emailed code”).signin_view_signin =
“Sign in”; the password button, the emailed-code request and
verify screen titles, and the verify button all resolve to it, so four
duplicate labels (signin_view_login and the emailed-code
_title/_enter_title/_verify_button)
are removed. The welcome-menu and page-options links also read
“Sign in”; adjective uses such as “sign-in code”
keep the hyphen. The word “login” is removed from the
user-facing message texts (cookie and sign-in prompts, “Signed
in!!”). The emailed-code locale keys are renamed from
*_login_code_* to *_email_code_* with all
tl() references updated, so the keys no longer carry
“login”. The only emailed-code-specific labels left are the
field label and the “Email me a code” button.LOGIN_CODE symbols to
SIGNIN_CODE; restore the emailed-code link
text. With the user-facing wording settled, the feature's
internal names now match: the LOGIN_CODE table becomes
SIGNIN_CODE, the LOGIN_CODE_* timing constants
become SIGNIN_CODE_*, and the controller and model members
(signinCode, processSigninData,
signinCodeComplete, setSigninCode and friends,
the SIGNIN_SIGN_PREFIX token tag, and the session and data
keys) drop “login” too. Because the table only ever existed
on the dev machine, the version-113 migration and the fresh schema are
edited in place to create SIGNIN_CODE directly — no
new migration, DATABASE_VERSION stays 113. The emailed-code
entry link, which the wording pass had over-collapsed into plain
“Sign in”, is restored to “Sign in with emailed
code”. SigninModelTest updated and green.QUESTIONS_RECOVERY, in which each member
types their own question and answer. Two columns are added to the
USERS table — RECOVERY_QUESTION (kept as
plain text so it can be shown back) and RECOVERY_ANSWER
(only a one-way hash is stored, the same way a password is) — via
a new upgradeDatabaseVersion114 migration, so
DATABASE_VERSION is now 114. UserModel gains
setRecoveryQuestion and a constant-time
checkRecoveryAnswer. Registration, the Manage-Account
security panel, and the airgapped recover screen now show a plain
question field and an answer field instead of the quiz dropdowns; the
recover screen is a single page that, once the username and captcha
clear, shows the member their own question with an answer box and the
new-password fields together. Email-link recovery is unchanged. After
sign-in, a member with no question set yet is reminded to add one. The
quiz machinery is removed end to end (the
getRecoveryQuestions,
selectQuestionsAnswers, checkRecoveryQuestions
and makeRecoveryQuestions methods, the
NUM_RECOVERY_QUESTIONS constant, the
register_view_recovery question strings, and the
session-based answer storage), and the obsolete
“edit recovery questions” locale link is dropped.
UserModelTest covers the store/verify round trip and the
empty-answer case.$recovery_answer_change flag and saved a session blob
rather than calling setRecoveryQuestion, so a typed
question and answer were silently dropped — which then made
question-based password reset fail because no answer had been stored.
The save branch now stores the question and answer (the answer hashed,
the same as at registration) once the confirming password checks out.
Recovery answers are verified against the raw typed text so storage and
verification hash the same value. The last dead remnants of the old
quiz are removed: the $recovery_answer_change guard and the
now-unused RegisterController, LocaleModel and
RegisterView imports the deleted
makeRecoveryQuestions had pulled in.setRecoveryQuestion now takes a null answer to mean
“leave it”); typing over the bullets sets a new answer. The
marker lives in one place as RECOVERY_ANSWER_PLACEHOLDER.
UserModelTest covers the question-only edit keeping the old
answer.NS_ERROR_NET_PARTIAL_TRANSFER: it downloaded about
128 KB, hung for roughly a minute, then finished or gave up;
Safari and Chrome were fine and loopback never reproduced it. A
Firefox log capture pinned it: Firefox enlarges a stream's window to
about 12 MB with a WINDOW_UPDATE the instant the stream opens,
but the server was not reading it. Over TLS, OpenSSL decrypts a whole
record into its own buffer while stream_select watches
only the raw socket, so a single read per wakeup took the request
headers and left the grant sitting decrypted-but-unseen until an
unrelated wakeup (Firefox's 58-second keep-alive) finally read it.
Three fixes: the read path now drains the decrypted buffer to empty
each wakeup, capped at the chunk size and stopping on the first empty
read, so the grant is read alongside the headers and the stream never
parks; the end-of-file close now waits until nothing is left to send
(no queued bytes, no parked send, no streaming generator), since the
drain exposes a spurious end-of-file on a non-blocking TLS stream that
used to tear the connection down mid-response; and the frame loop now
dispatches every request that arrived in one read, not just the first,
so a page plus its style sheets, scripts, and images all load instead
of stalling at zero bytes. The investigation tracing and an earlier
ping-on-park nudge were then removed. H3Listener was
reviewed and needs none of these: QUIC datagrams are discrete, its
read loop already drains every datagram, its drive step already
iterates every stream, and QUIC's own close replaces TCP end-of-file.
Covered by readCreditThenKeepsFlushingPastEofTestCase and
readDispatchesEveryRequestInOneReadTestCase.closedStreamWindowUpdateForClosedStreamIgnoredTestCase
and preGrantForOpenStreamStillAppliedTestCase.